Hyperparameter optimization

(optional, but encouraged–especially for determining the optimal number of inducing points)


[1]:
import numpy as np
import pandas as pd
import os

import TRANSPIRE

1. Load data, define comparisons, and generate synthetic translocations

see this notebook for greater detail

[2]:
f = 'mydata.csv'
df = TRANSPIRE.data.import_data.load_data(f)

comparisons = [('uninfected', 'infected')]

synthetic_translocations = TRANSPIRE.data.generate_translocations.make_translocations(df, comparisons)
mapping, mapping_r = TRANSPIRE.utils.get_mapping(df)

synthetic_translocations.head()
[2]:
1 2 3 4 5 6 7 8 9 10 11 12
accession_A gene name_A localization_A condition_A accession_B gene name_B localization_B condition_B label
O00115 DNASE2 ER/Golgi/Lysosome uninfected O00115 DNASE2 ER/Golgi/Lysosome infected ER/Golgi/Lysosome to ER/Golgi/Lysosome 0.121081 0.378059 0.287889 0.142575 0.056295 0.014235 0.091988 0.249011 0.334238 0.181254 0.104927 0.041090
O00116 AGPS Peroxisome infected ER/Golgi/Lysosome to Peroxisome 0.121081 0.378059 0.287889 0.142575 0.056295 0.014235 0.035467 0.087956 0.190615 0.228672 0.353767 0.095804
O00151 PDLIM1 PM/Cytosol infected ER/Golgi/Lysosome to PM/Cytosol 0.121081 0.378059 0.287889 0.142575 0.056295 0.014235 0.298505 0.273591 0.136350 0.124446 0.101276 0.051836
O00161 SNAP23 PM/Cytosol infected ER/Golgi/Lysosome to PM/Cytosol 0.121081 0.378059 0.287889 0.142575 0.056295 0.014235 0.303401 0.275456 0.219409 0.094259 0.065571 0.027320
O00186 STXBP3 PM/Cytosol infected ER/Golgi/Lysosome to PM/Cytosol 0.121081 0.378059 0.287889 0.142575 0.056295 0.014235 0.251250 0.279922 0.220812 0.102742 0.089897 0.047200

2. Split the data into training, validation and testing partitions

[3]:
f_train = 0.5 # fraction dedicated to training data
f_validate = 0.25 # fraction dedicated to validation (i.e. used to assess hyperparameter performance)
f_test = 0.25 # fraction dedicated to assessing final predictive performance

levels = ['condition_A', 'condition_B', 'label'] # groupby levels to take stratified samples from

X_train_df, X_validate_df, x_test_df = TRANSPIRE.utils.train_test_validate_split(synthetic_translocations,
                                                                                 levels,
                                                                                 f_train,
                                                                                 f_validate,
                                                                                 f_test)
Splitting data into training, validation, and testing folds (this may take a while) . . . done

3. Combine profiles across conditions to reduce computational load

We assume that the inherent nature of the data does not vary between conditions, and thus whichever hyperparameters perform best on all conditions combined should also perform best on the conditions individually. Combining the conditions and training the models on the mean value for a given profile across conditions can drastically reduce the computational load of hyperparameter optimization

Note that in the case of this dataset, this doesn’t have an affect since we only have two conditions to compare. However, this will save time in cases where your dataset has more than 2 conditions.

[4]:
X_train_mean = X_train_df.groupby([n for n in X_train_df.index.names if not 'condition' in n]).mean()
X_validate_mean = X_validate_df.groupby([n for n in X_validate_df.index.names if not 'condition' in n]).mean()

X_train_mean.head() # notice that this dataframe doesn't have any "condition" levels
[4]:
1 2 3 4 5 6 7 8 9 10 11 12
accession_A gene name_A localization_A accession_B gene name_B localization_B label
O00115 DNASE2 ER/Golgi/Lysosome O00115 DNASE2 ER/Golgi/Lysosome ER/Golgi/Lysosome to ER/Golgi/Lysosome 0.121081 0.378059 0.287889 0.142575 0.056295 0.014235 0.091988 0.249011 0.334238 0.181254 0.104927 0.041090
O00116 AGPS Peroxisome ER/Golgi/Lysosome to Peroxisome 0.121081 0.378059 0.287889 0.142575 0.056295 0.014235 0.035467 0.087956 0.190615 0.228672 0.353767 0.095804
O00151 PDLIM1 PM/Cytosol ER/Golgi/Lysosome to PM/Cytosol 0.121081 0.378059 0.287889 0.142575 0.056295 0.014235 0.298505 0.273591 0.136350 0.124446 0.101276 0.051836
O00217 NDUFS8 Mitochondria ER/Golgi/Lysosome to Mitochondria 0.121081 0.378059 0.287889 0.142575 0.056295 0.014235 0.017173 0.035831 0.172065 0.405464 0.262308 0.095967
O00483 NDUFA4 Mitochondria ER/Golgi/Lysosome to Mitochondria 0.121081 0.378059 0.287889 0.142575 0.056295 0.014235 0.017179 0.039236 0.191476 0.424399 0.219197 0.087788

4. Split the training data into a given number of class-balanced folds

Depending on the number of markers in each class, the synthetic translocations generated may have a high level of class-imbalance. If there is overlap between organelle marker clases, this can result in classifier predictions that are biased toward the class with more samples.

To prevent prediction bias due to class imbalance, TRANSPIRE has a utility for generating different folds of balanced training data based on a given “imbalance_limit.”

In general, more folds will yield better coverage of the training data, but at the expense of computation time. A highter imbalance_limit will overall increase coverage of the training data for a given fold, but may lead to poor model performance if there is high overlap between classes.

Note: In contrast to k-fold cross-validation, these folds will overlap for any classes where the number of samples is less-than n_limit * n_folds

[5]:
imbalance_limit = 3 # maximum fold difference between smallest and largest class sizes
n_limit = X_train_mean.groupby('label').size().min()*imbalance_limit

print('The total number of folds required to cover all of the training data for each class is: {}'.format(np.ceil((X_train_mean.groupby('label').size()/n_limit).max())))
The total number of folds required to cover all of the training data for each class is: 129.0
[6]:
n_folds = 5 # number of balanced folds to sample from the dataset
training_data = TRANSPIRE.utils.sample_balanced(X_train_mean, n_limit, n_folds)
Creating balanced training partitions (this may take a while).... done

5. Define possible combinations of hyperparameters

(e.g. kernel type and number of inducing points)

In general, the number of inducing points required will heavily depend on the extent of class separation, class number, and overall data quality. Below is a good starting point, but it should be adjusted on a per-dataset basis.

[7]:
possible_n_induce = [1, 5, 10, 30, 75]

In our hands, the choice of kernel function has had a much lower impact on final model performance. Below are five different stationary kernels that are implemented in GPFlow. Again, the optimal kernel will ultimately vary dataset to dataset, but the choices below are a good starting point.

[8]:
import tensorflow as tf
tf.logging.set_verbosity(tf.logging.ERROR)

import gpflow

possible_kernel_funcs = [
    gpflow.kernels.SquaredExponential,
    gpflow.kernels.RationalQuadratic,
    gpflow.kernels.Matern32,
    gpflow.kernels.Matern52,
    gpflow.kernels.Exponential
]

[9]:
import itertools

# create all possible combinations of the different n_induce, kernel_func options
possible_hyperparams = list(itertools.product(possible_n_induce, possible_kernel_funcs))

possible_hyperparams
[9]:
[(1, gpflow.kernels.SquaredExponential),
 (1, gpflow.kernels.RationalQuadratic),
 (1, gpflow.kernels.Matern32),
 (1, gpflow.kernels.Matern52),
 (1, gpflow.kernels.Exponential),
 (5, gpflow.kernels.SquaredExponential),
 (5, gpflow.kernels.RationalQuadratic),
 (5, gpflow.kernels.Matern32),
 (5, gpflow.kernels.Matern52),
 (5, gpflow.kernels.Exponential),
 (10, gpflow.kernels.SquaredExponential),
 (10, gpflow.kernels.RationalQuadratic),
 (10, gpflow.kernels.Matern32),
 (10, gpflow.kernels.Matern52),
 (10, gpflow.kernels.Exponential),
 (30, gpflow.kernels.SquaredExponential),
 (30, gpflow.kernels.RationalQuadratic),
 (30, gpflow.kernels.Matern32),
 (30, gpflow.kernels.Matern52),
 (30, gpflow.kernels.Exponential),
 (75, gpflow.kernels.SquaredExponential),
 (75, gpflow.kernels.RationalQuadratic),
 (75, gpflow.kernels.Matern32),
 (75, gpflow.kernels.Matern52),
 (75, gpflow.kernels.Exponential)]

6. Compute inducing point arrays up-front (optional)

There are no downsides to performing this step, but it will prevent repeat KMeans fitting of the inducing points in the event that there are multiple hyperparameter combinations with the same n_induce

[23]:
import TRANSPIRE.training

# fit KMeans to training data for different n_induce
Z_induce = {}

for n_induce in possible_n_induce:

    Z_induce[n_induce] = {}

    for k, x in training_data.groupby('fold'):

        Z_induce[n_induce][k] = TRANSPIRE.training.compute_inducing(x, n_induce)

    print(n_induce, end=', ')
1, 5, 10, 30, 75,

7. Train and save models for each combination of hyperparameters

define static model parameters

[10]:
# where to save the models (make sure that this path exists)
base_dir = 'hyperparameter_opt'

# max number of calls to the optimizer for each model that is built
maxiter = 200

# learning rate for the Adam optimizer
# too low of a learning rate will prevent the model from reaching an optimal solution
# too high and the fitting may diverge
# 0.05 is a good starting point
learning_rate = 0.05

# whether to implement interactive plotting to track fit progress
plot_fit = True

custom callback to visualize model training

[24]:
%matplotlib inline

from IPython import display

class PlottingCallback(TRANSPIRE.training.ProgressTracker):
    def __init__(self, m, X, Y, update_interval):
        super().__init__(m, X, Y)

        self.calls = 0
        self.update_interval = update_interval
        self.update()

        ax.set_xlim(0, maxiter+1)
        ax.set_ylim(self.elbo[0], 0)
        ax_2.set_ylim(0, 1)

    def update_plot(self, i):
        self.calls+=1
        self.update()

        if self.calls%self.update_interval == 0:
            ax.set_title('iteration # {}'.format(self.calls))

            x = [int(self.update_interval*i) for i in range(int(np.ceil(len(self.elbo)/self.update_interval)))]
            elbo = [np.mean(np.array(self.elbo)[x[i]:x[i+1]]) for i in range(len(x)-1)]
            acc = [np.mean(np.array(self.acc)[x[i]:x[i+1]]) for i in range(len(x)-1)]

            elbo_line, = ax.plot(x[:-1], elbo, color = 'grey', label='ELBO')
            acc_line, = ax_2.plot(x[:-1], acc, color='steelblue', label= 'accuracy')

            ax.set_xlabel('iteration #')
            ax.set_ylabel('ELBO')
            ax_2.set_ylabel('accuracy')

            ax.legend(handles=[elbo_line, acc_line], loc=4)

            fig.tight_layout()

            display.clear_output(wait=True)
            display.display(fig)
[27]:

if plot_fit == True:

    import matplotlib.pyplot as plt
    plt.rcParams.update({'font.size': 16})

    # for tracking fitting progress
    fig = plt.figure(figsize = (8, 5))
    ax = fig.add_subplot(111)
    ax_2 = ax.twinx()
    plt.show()


# just in case the training process is stopped part way through
gpflow.reset_default_graph_and_session()

for n_induce, kern_func in possible_hyperparams:

    # make a new directory for the models denoting the model architecture
    hyper_dir = os.path.join(base_dir, 'n={}_kernel={}'.format(n_induce, str(kern_func).split('.')[-1].strip("'>")))

    # if this directory doesn't alredy exist, make it
    if not os.path.exists(hyper_dir):
        os.mkdir(hyper_dir)

    # build and fit a model for each balanced fold of training data
    for k, X_train in training_data.groupby('fold'):

        # check if a model with this architecture already exists
        # (useful if you want to pause and resume training)
        if not k in [int(f.split('_')[-1]) for f in os.listdir(hyper_dir)]:

            if plot_fit == True:
                ax.clear()
                ax_2.clear()

            X = X_train.values
            y = X_train.index.get_level_values('label').map(mapping).values.reshape(-1, 1) # encode labels
            Z = Z_induce[n_induce][k] # set of inducing points already pre-calculated

            # define kernel function (with noise kernel added)
            kernel = kern_func(X.shape[1], ARD=True) + gpflow.kernels.White(X.shape[1], variance=1)

            # parameters required to build model
            model_params = {

                'Z': Z, # if Z is omitted from this dict, KMeans will be used to find n_inducing points during moldel generation
                'n_induce': n_induce, # only really required if Z is omitted
                'kernel': kernel,
            }

            # build the SVGP model
            m = TRANSPIRE.training.build_model(X, y, **model_params)

            # allow the optimizer to train both inducing points and the noise level
            # (optional...one or more may be set to false--up to user-discretion)
            m.feature.Z.trainable = True
            m.kern.kernels[1].trainable = True

            session = gpflow.get_default_session()

            if plot_fit == True:

                window = max(1, int(maxiter/25))
                callback = PlottingCallback(m, X, y, window)

                opt = gpflow.training.AdamOptimizer(learning_rate=learning_rate)
                opt.minimize(m, maxiter=maxiter, step_callback=callback.update_plot)

            else:

                opt = gpflow.training.AdamOptimizer(learning_rate=learning_rate)
                opt.minimize(m, maxiter=maxiter)

            # save the optimized model
            m.anchor(session)
            gpflow.saver.Saver().save(os.path.join(hyper_dir, 'ELBO={:.0f}_{}'.format(m.compute_log_likelihood(X), k)), m)

            # this line is EXTREMELY IMPORTANT to prevent the tensorflow graph from continuing to grow call-after-call
            gpflow.reset_default_graph_and_session()

../_images/notebooks_hyperparameter_optimization_22_0.png

8. Evaluate each model on the validation data

[11]:
results = {}

eval_limit = 10000 # max number of samples to evaluate at once (conserve RAM usage)
n_chunks = int(np.ceil(X_validate_mean.shape[0]/eval_limit))

for n_induce, kern_func in possible_hyperparams:

    hyper_dir = os.path.join(base_dir, 'n={}_kernel={}'.format(n_induce, str(kern_func).split('.')[-1].strip("'>")))

    if not os.path.exists(hyper_dir):

       raise FileNotFoundError()

    # iterate through files (e.g. models) in the directory
    for file in os.listdir(hyper_dir):

        fold = file.split('_')[-1]
        key = (n_induce, str(kern_func).split('.')[-1].strip("'>"), fold)

        # load the optimized model from its directory
        m = gpflow.saver.Saver().load(os.path.join(hyper_dir, file))

        # iterate through the validation partition and evaluate model performance, save results in a dataframe
        means = []

        for i, arr in enumerate(np.array_split(X_validate_mean, n_chunks)):
            means.append(pd.DataFrame(m.predict_y(arr.values)[0], index=arr.index))

        # this line is EXTREMELY IMPORTANT to prevent the tensorflow graph from continuing to grow call-after-call
        gpflow.reset_default_graph_and_session()

        # combine results into a single dataframe
        means = pd.concat(means)

        # compute metrics to evaluate performance....
        results[key] = TRANSPIRE.performance.eval_report(means, mapping, mapping_r)

    print(n_induce, kern_func)

# combine results into a single dataframe
results = pd.concat(results, names = ['n induce', 'kernel', 'fold'])

# save results so you don't have to do the training again!
results.to_frame().to_csv(os.path.join(base_dir, 'hyperparameter_opt_results.csv'))
1 <class 'gpflow.kernels.SquaredExponential'>
1 <class 'gpflow.kernels.RationalQuadratic'>
1 <class 'gpflow.kernels.Matern32'>
1 <class 'gpflow.kernels.Matern52'>
1 <class 'gpflow.kernels.Exponential'>
5 <class 'gpflow.kernels.SquaredExponential'>
5 <class 'gpflow.kernels.RationalQuadratic'>
5 <class 'gpflow.kernels.Matern32'>
5 <class 'gpflow.kernels.Matern52'>
5 <class 'gpflow.kernels.Exponential'>
10 <class 'gpflow.kernels.SquaredExponential'>
10 <class 'gpflow.kernels.RationalQuadratic'>
10 <class 'gpflow.kernels.Matern32'>
10 <class 'gpflow.kernels.Matern52'>
10 <class 'gpflow.kernels.Exponential'>
30 <class 'gpflow.kernels.SquaredExponential'>
30 <class 'gpflow.kernels.RationalQuadratic'>
30 <class 'gpflow.kernels.Matern32'>
30 <class 'gpflow.kernels.Matern52'>
30 <class 'gpflow.kernels.Exponential'>
75 <class 'gpflow.kernels.SquaredExponential'>
75 <class 'gpflow.kernels.RationalQuadratic'>
75 <class 'gpflow.kernels.Matern32'>
75 <class 'gpflow.kernels.Matern52'>
75 <class 'gpflow.kernels.Exponential'>
[19]:
results
[19]:
n induce  kernel              fold  type of metric        metric
1         SquaredExponential  5     F1 score (per class)  DC/Nucleus to DC/Nucleus           0.000000
                                                          DC/Nucleus to ER/Golgi/Lysosome    0.042781
                                                          DC/Nucleus to Mitochondria         0.042105
                                                          DC/Nucleus to PM/Cytosol           0.000000
                                                          DC/Nucleus to Peroxisome           0.000000
                                                                                               ...
75        Exponential         5     singular metrics      F1 score (micro)                   0.989231
                                                          F1 score (macro)                   0.946134
                                                          F1 score (weighted)                0.989584
                                                          loss (binary)                      0.054023
                                                          F1 score (weighted, binary)        0.993230
Length: 3750, dtype: float64

9. Visualize the results and determine optimal hyperparameters

The cell below will plot two different kinds of metrics to help you decide what hyperparameters are the best. Note that the best F1 score is 1.0 and the worst F1 score is 0.0. On the other hand, the best loss is 0.0. In the binary classification setting, a classifier that is uninformative (e.g. no better than chance) will have a loss of >=0.7. In the multi-class case, an uninformative classifier will yield loss >= -log(1/M) where M is the total number of classes.

[21]:
import seaborn as sns
import matplotlib.pyplot as plt

%matplotlib inline

temp = results.unstack('type of metric')['singular metrics'].dropna()
sns.catplot(data=temp.reset_index().melt(temp.index.names), col='metric',
            hue='kernel', sharey=False, x='n induce', y='value', kind='point')

plt.show()
../_images/notebooks_hyperparameter_optimization_27_0.png

You can now take your chosen hyperparameters forward for final model fitting

For this dataset, it appears that in general, the squared exponential, matern, and rational quatratic kernels all perform relatively similarly on the data. The exponential kernel, however, performs worse. We can also see that this data is sufficiently described by ~30 inducing points.